{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "IXkwmVQHOSum"
   },
   "source": [
    "# Tutorial 5: Adversarial Autoencoder\n",
    "\n",
    "**Author** - [Yatin Dandi](https://yatindandi.github.io)\n",
    "\n",
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/torchgan/torchgan/blob/master/tutorials/Tutorial%205.%20Adversarial%20Autoencoder.ipynb)\n",
    "\n",
    "In this tutorial we will explore **Adversarial Autoencoders** (AAE), which use Generative Adversarial Networks to perform variational inference. As explained in [Adversarial Autoencoders (Makhzani et. al.)](https://arxiv.org/pdf/1511.05644), the aggregated posterior distribution of the latent representation of the autoencoder is matched to an arbitrary prior distribution using adversarial training.\n",
    "\n",
    "The tutorial helps you with the following:\n",
    "\n",
    "1. General workflow for implementing GAN models with inference networks using **TorchGAN**\n",
    "2. Implementing custom losses with a nonstandard training loop"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "s9LuHgr9SHsl"
   },
   "source": [
    "This tutorial assumes that your system has **PyTorch** and **TorchGAN** installed properly. If not, the following code block will try to install the **latest tagged version** of TorchGAN. If you need to use some other version head over to the installation instructions on the [official documentation website](https://torchgan.readthedocs.io/en/latest/)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "8Ic9BDViSMy_"
   },
   "outputs": [],
   "source": [
    "try:\n",
    "    import torchgan\n",
    "\n",
    "    print(f\"Existing TorchGAN {torchgan.__version__} installation found\")\n",
    "except ImportError:\n",
    "    import subprocess\n",
    "    import sys\n",
    "\n",
    "    subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"torchgan\"])\n",
    "    import torchgan\n",
    "\n",
    "    print(f\"Installed TorchGAN {torchgan.__version__}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "oHbzUBALVh0e"
   },
   "source": [
    "## IMPORTS"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "RbhNNO3hUazo"
   },
   "outputs": [],
   "source": [
    "# General Imports\n",
    "import os\n",
    "import random\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.animation as animation\n",
    "import numpy as np\n",
    "from IPython.display import HTML\n",
    "\n",
    "# Pytorch and Torchvision Imports\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import torchvision\n",
    "import torchvision.transforms as T\n",
    "from torch.optim import Adam\n",
    "import torch.utils.data as data\n",
    "import torchvision.datasets as dsets\n",
    "import torchvision.transforms as transforms\n",
    "import torchvision.utils as vutils\n",
    "\n",
    "# Torchgan Imports\n",
    "import torchgan.models as models\n",
    "import torchgan.losses as losses\n",
    "from torchgan.trainer import Trainer"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 34
    },
    "colab_type": "code",
    "id": "dlW4KWWwVG3K",
    "outputId": "9f7e10cc-a973-4385-c648-ab742c35ba26"
   },
   "outputs": [],
   "source": [
    "# Set random seed for reproducibility\n",
    "manualSeed = 999\n",
    "random.seed(manualSeed)\n",
    "torch.manual_seed(manualSeed)\n",
    "print(\"Random Seed: \", manualSeed)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "jCvdpny7W4wO"
   },
   "source": [
    "## LOAD THE DATASET"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "bPVu8LQ8y_wP"
   },
   "source": [
    "We make the following transforms before feeding the **MNIST Dataset** into the networks\n",
    "\n",
    "1. The default size of MNIST is $1 \\times 28 \\times 28$. However, by convention, the default input size in **torchgan.models** is a power of 2 and at least 16. Hence we shall be resizing the images to $1 \\times 32 \\times 32$.  One can also **zero-pad** the boundary, without any noticeable difference \n",
    "\n",
    "2. The output quality of GANs is improved when the images are constrained in the range The images are normalized with a mean and standard deviation of **0.5** , thereby constraining most of the inputs in the range (-1, 1)\n",
    "\n",
    "Finally the **torchgan.trainer.Trainer** needs a **DataLoader** as input. So we are going to construct a DataLoader for the MNIST Dataset."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "C6wWnHlMW38A"
   },
   "outputs": [],
   "source": [
    "dataset = dsets.MNIST(\n",
    "    root=\"./mnist\",\n",
    "    train=True,\n",
    "    transform=transforms.Compose(\n",
    "        [\n",
    "            transforms.Resize((32, 32)),\n",
    "            transforms.ToTensor(),\n",
    "            transforms.Normalize(mean=(0.5,), std=(0.5,)),\n",
    "        ]\n",
    "    ),\n",
    "    download=True,\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "BI1w2110zOXC"
   },
   "outputs": [],
   "source": [
    "loader = data.DataLoader(dataset, batch_size=64, shuffle=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "mmn8UTFfWA54"
   },
   "source": [
    "## GENERATOR MODEL"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "dsIGe8cCyx8W"
   },
   "source": [
    "The AAE Generator corresponds to the encoder of the autoencoder. It takes as input an image in the form of a torch Tensor of size $batch\\ size \\times 1 \\times 32 \\times 32$ and outputs a tuple of  reconstructed images of size $batch\\ size \\times 1 \\times 32 \\times 32$ and the encoding of size $batch\\ size \\times \\ encoding \\ dims$. The sampling of images is done by feeding the noise through the decoder. Unlike GANs, AAE allows inference of latent encodings from images which can be used for various downstream tasks. The distribution of the sampled encodings from the dataset (the aggregated posterior) is encouraged to match the prior distribution through adversarial training. Here we have implemented the deterministic version of the autoencoder which gives similar results to the variants with gaussian/universal approximator posterior as described in Adversarial Autoencoders (Makhzani et. al.)\n",
    "\n",
    " **For encoder**:\n",
    "\n",
    "1. **Channel Dimension**: $input \\ channels \\rightarrow d \\rightarrow 4d \\rightarrow 16d \\rightarrow \\ encoding \\ dims$.\n",
    "2. **Image size**: $(32 \\times 32) \\rightarrow (16 \\times 16) \\rightarrow (4 \\times 4) \\rightarrow (1 \\times 1)$\n",
    "\n",
    "**For decoder (sampling)**:\n",
    "\n",
    "1. **Channel Dimension**: $\\ encoding \\ dims \\rightarrow d \\rightarrow 4d \\rightarrow 16d \\rightarrow input \\ channels$.\n",
    "2. **Image size**: $(1 \\times 1) \\rightarrow (4 \\times 4) \\rightarrow (16 \\times 16) \\rightarrow (32 \\times 32)$\n",
    "\n",
    "LeakyReLU is used as the default nonlinearity in both the encoder and the decoder as Relu kills most of the gradients.  One can easily change the nonlinearity of the intermediate and the last layers as per their preference by passing them as parameters during initialization of the Generator object."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "Ch9UGVDeWJZO"
   },
   "outputs": [],
   "source": [
    "class AdversarialAutoencoderGenerator(models.Generator):\n",
    "    def __init__(\n",
    "        self,\n",
    "        encoding_dims,\n",
    "        input_size,\n",
    "        input_channels,\n",
    "        step_channels=16,\n",
    "        nonlinearity=nn.LeakyReLU(0.2),\n",
    "    ):\n",
    "        super(AdversarialAutoencoderGenerator, self).__init__(encoding_dims)\n",
    "        encoder = [\n",
    "            nn.Sequential(\n",
    "                nn.Conv2d(input_channels, step_channels, 5, 2, 2), nonlinearity\n",
    "            )\n",
    "        ]\n",
    "        size = input_size // 2\n",
    "        channels = step_channels\n",
    "        while size > 1:\n",
    "            encoder.append(\n",
    "                nn.Sequential(\n",
    "                    nn.Conv2d(channels, channels * 4, 5, 4, 2),\n",
    "                    nn.BatchNorm2d(channels * 4),\n",
    "                    nonlinearity,\n",
    "                )\n",
    "            )\n",
    "            channels *= 4\n",
    "            size = size // 4\n",
    "        self.encoder = nn.Sequential(*encoder)\n",
    "        self.encoder_fc = nn.Linear(\n",
    "            channels, encoding_dims\n",
    "        )  # Can add a Tanh nonlinearity if training is unstable as noise prior is Gaussian\n",
    "        self.decoder_fc = nn.Linear(encoding_dims, step_channels)\n",
    "        decoder = []\n",
    "        size = 1\n",
    "        channels = step_channels\n",
    "        while size < input_size // 2:\n",
    "            decoder.append(\n",
    "                nn.Sequential(\n",
    "                    nn.ConvTranspose2d(channels, channels * 4, 5, 4, 2, 3),\n",
    "                    nn.BatchNorm2d(channels * 4),\n",
    "                    nonlinearity,\n",
    "                )\n",
    "            )\n",
    "            channels *= 4\n",
    "            size *= 4\n",
    "        decoder.append(nn.ConvTranspose2d(channels, input_channels, 5, 2, 2, 1))\n",
    "        self.decoder = nn.Sequential(*decoder)\n",
    "\n",
    "    def sample(self, noise):\n",
    "        noise = self.decoder_fc(noise)\n",
    "        noise = noise.view(-1, noise.size(1), 1, 1)\n",
    "        return self.decoder(noise)\n",
    "\n",
    "    def forward(self, x):\n",
    "        if self.training:\n",
    "            encoding = self.encoder(x)\n",
    "            encoding = self.encoder_fc(\n",
    "                encoding.view(\n",
    "                    -1, encoding.size(1) * encoding.size(2) * encoding.size(3)\n",
    "                )\n",
    "            )\n",
    "            return self.sample(encoding), encoding\n",
    "        else:\n",
    "            return self.sample(x)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "IioEC9hl0ieg"
   },
   "source": [
    "## DISCRIMINATOR NETWORK"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "IBbjyLNF0agI"
   },
   "source": [
    "The AAE discriminator is trained to classify an input noise vector as being real(sampled from the prior) or fake (generated by the encoder). It is thus a simple MLP which outputs the logits to be used with the minimax loss.\n",
    "\n",
    "For reasons same as above we use a **Leaky ReLU** activation. The encoding size is halved successively till it reaches the threshold of 16. The last linear layer subsequently converts this to real output for the logit."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "5ASUHLE00xTe"
   },
   "outputs": [],
   "source": [
    "class AdversarialAutoencoderDiscriminator(models.Discriminator):\n",
    "    def __init__(self, input_dims, nonlinearity=nn.LeakyReLU(0.2)):\n",
    "        super(AdversarialAutoencoderDiscriminator, self).__init__(input_dims)\n",
    "        model = [nn.Sequential(nn.Linear(input_dims, input_dims // 2), nonlinearity)]\n",
    "        size = input_dims // 2\n",
    "        while size > 16:\n",
    "            model.append(\n",
    "                nn.Sequential(\n",
    "                    nn.Linear(size, size // 2), nn.BatchNorm1d(size // 2), nonlinearity\n",
    "                )\n",
    "            )\n",
    "            size = size // 2\n",
    "        model.append(nn.Linear(size, 1))\n",
    "        self.model = nn.Sequential(*model)\n",
    "\n",
    "    def forward(self, x):\n",
    "        return self.model(x)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "gt1Wk3nm5z61"
   },
   "source": [
    "## LOSS FUNCTIONS"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "wTA22HBH6OB5"
   },
   "source": [
    "The loss function of the autoencoder consists of two terms:\n",
    "\n",
    "1. The reconstruction error: Mean squared error between the input image and the reconstruction.\n",
    "2. The negative log likelihood of the generated noise (encodings) w.r.t to the discriminator.\n",
    "\n",
    "The loss function of the discriminator consists of the minimax loss with the encodings generated by the encoder as the fake samples and the noise generated by the prior as the real samples.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "YI9F3YAB6PDX"
   },
   "outputs": [],
   "source": [
    "class AdversarialAutoencoderGeneratorLoss(losses.GeneratorLoss):\n",
    "    def forward(self, real_inputs, gen_inputs, dgz):\n",
    "        loss = 0.999 * F.mse_loss(gen_inputs, real_inputs)\n",
    "        target = torch.ones_like(dgz)\n",
    "        loss += 0.001 * F.binary_cross_entropy_with_logits(dgz, target)\n",
    "        return loss\n",
    "\n",
    "    def train_ops(\n",
    "        self,\n",
    "        generator,\n",
    "        discriminator,\n",
    "        optimizer_generator,\n",
    "        real_inputs,\n",
    "        device,\n",
    "        batch_size,\n",
    "        labels=None,\n",
    "    ):\n",
    "        recon, encodings = generator(real_inputs)\n",
    "        optimizer_generator.zero_grad()\n",
    "        dgz = discriminator(encodings)\n",
    "        loss = self.forward(real_inputs, recon, dgz)\n",
    "        loss.backward()\n",
    "        optimizer_generator.step()\n",
    "        return loss.item()\n",
    "\n",
    "\n",
    "class AdversarialAutoencoderDiscriminatorLoss(losses.DiscriminatorLoss):\n",
    "    def forward(self, dx, dgz):\n",
    "        target_real = torch.ones_like(dx)\n",
    "        target_fake = torch.zeros_like(dx)\n",
    "        loss = 0.5 * F.binary_cross_entropy_with_logits(dx, target_real)\n",
    "        loss += 0.5 * F.binary_cross_entropy_with_logits(dgz, target_fake)\n",
    "        return loss\n",
    "\n",
    "    def train_ops(\n",
    "        self,\n",
    "        generator,\n",
    "        discriminator,\n",
    "        optimizer_discriminator,\n",
    "        real_inputs,\n",
    "        device,\n",
    "        batch_size,\n",
    "        labels=None,\n",
    "    ):\n",
    "        _, encodings = generator(real_inputs)\n",
    "        noise = torch.randn(real_inputs.size(0), generator.encoding_dims, device=device)\n",
    "        optimizer_discriminator.zero_grad()\n",
    "        dx = discriminator(noise)\n",
    "        dgz = discriminator(encodings)\n",
    "        loss = self.forward(dx, dgz)\n",
    "        loss.backward()\n",
    "        optimizer_discriminator.step()\n",
    "        return loss.item()\n",
    "\n",
    "\n",
    "losses = [\n",
    "    AdversarialAutoencoderGeneratorLoss(),\n",
    "    AdversarialAutoencoderDiscriminatorLoss(),\n",
    "]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "nPID94dYHE_O"
   },
   "source": [
    "## OPTIMIZERS AND HYPERPARAMETERS"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "UOgtK8ckHFj7"
   },
   "source": [
    "The models, their corresponding optimizers and other hyperparameters like the nonlinearities to be used in the intermediate layers are bundled in the form of a dictionary and provided to the trainer for instantiation. The dictionary specifies the models that are to be trained, the optimizers associated with said models and learning rate schedulers, if any\n",
    "\n",
    "\n",
    "1. \"name\": The class name for the model. Generally a subclass of the ```torchgan.models.Generator``` or ```torchgan.models.Discriminator```\n",
    "2. \"args\": Arguments fed into the class during instantiation, into its constructor \n",
    "3. \"optimizer\": A dictionary containing the following key-value pairs defining the optimizer associated with the model\n",
    "    * \"name\" : The class name of the optimizer. Generally an optimizer from the ```torch.optim``` package\n",
    "    * \"args\" : Arguments to be fed to the optimizer during its instantiation, into its constructor\n",
    "    * \"var\": Variable name for the optimizer. This is an optional argument. If this is not provided, we assign the optimizer the name ```optimizer_{}``` where {} refers to the variable name of the model.\n",
    "    * \"scheduler\": Optional scheduler associated with the optimizer. Again this is a dictionary with the following keys\n",
    "        * \"name\" : Class name of the scheduler\n",
    "        * \"args\" : Arguments to be provided to the scheduler during instantiation, into its constructor"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "Ii1SX2mKHd1p"
   },
   "outputs": [],
   "source": [
    "network = {\n",
    "    \"generator\": {\n",
    "        \"name\": AdversarialAutoencoderGenerator,\n",
    "        \"args\": {\"encoding_dims\": 128, \"input_size\": 32, \"input_channels\": 1},\n",
    "        \"optimizer\": {\"name\": Adam, \"args\": {\"lr\": 0.0002, \"betas\": (0.5, 0.999)}},\n",
    "    },\n",
    "    \"discriminator\": {\n",
    "        \"name\": AdversarialAutoencoderDiscriminator,\n",
    "        \"args\": {\"input_dims\": 128,},\n",
    "        \"optimizer\": {\"name\": Adam, \"args\": {\"lr\": 0.0002, \"betas\": (0.5, 0.999)}},\n",
    "    },\n",
    "}"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "9ernMbQ2HjVV"
   },
   "source": [
    "## TRAINING THE ADVERSARIAL AUTOENCODER"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "w8mduEi_H8pQ"
   },
   "source": [
    "Next we simply feed the network descriptors and the losses we defined previously into the Trainer. Then we pass the **MNIST DataLoader** to the trainer object and wait for training to complete.\n",
    "\n",
    "---\n",
    "\n",
    "Important information for visualizing the performance of the GAN will be printed to the console. The best and recommended way to visualize the training is to use **tensorboardX**. It plots all the data and periodically displays the generated images. It allows us to track failure of the model early."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*NB: Training the models are quite expensive. Hence we will train the models for **10** epochs if a GPU is available, else we will be training for only **5** epochs. We recommend using the **GPU runtime** in Colab. The images will not look even close to realistic in **5** epochs but shall be enough to show that it is learning to generate good quality images. If you have access to powerful GPUs or want to see realistic samples, I would recommend simply increasing the **epochs** variable (to around **200**) in the next code block.*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if torch.cuda.is_available():\n",
    "    device = torch.device(\"cuda:0\")\n",
    "    # Use deterministic cudnn algorithms\n",
    "    torch.backends.cudnn.deterministic = True\n",
    "    epochs = 10\n",
    "else:\n",
    "    device = torch.device(\"cpu\")\n",
    "    epochs = 5\n",
    "\n",
    "print(\"Device: {}\".format(device))\n",
    "print(\"Epochs: {}\".format(epochs))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 1737
    },
    "colab_type": "code",
    "id": "9h64vFXEH_Hp",
    "outputId": "992ac79e-28a3-4b5a-8c22-226d29ec9922"
   },
   "outputs": [],
   "source": [
    "trainer = Trainer(network, losses, sample_size=64, epochs=epochs, device=device)\n",
    "trainer(loader)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "GCjxHh8snJlP"
   },
   "source": [
    "## VISUALIZING THE SAMPLES"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "colab_type": "text",
    "id": "1ggRLKJznDqi"
   },
   "source": [
    "Once training is complete, one can easily visualize the loss curves, gradient flow and sampled images per epoch on either the **TensorboardX** or **Vizdom** backends. For the purposes of this tutorial, we plot some of the sampled images here itself.\n",
    "\n",
    "*NB: It is highly recommended to view the results on TensorboardX or Vizdom if you are running this tutorial locally*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "exIqMv1InLFo"
   },
   "outputs": [],
   "source": [
    "# Grab a batch of real images from the dataloader\n",
    "real_batch = next(iter(loader))\n",
    "\n",
    "# Plot the real images\n",
    "plt.figure(figsize=(10, 10))\n",
    "plt.subplot(1, 2, 1)\n",
    "plt.axis(\"off\")\n",
    "plt.title(\"Real Images\")\n",
    "plt.imshow(\n",
    "    np.transpose(\n",
    "        vutils.make_grid(\n",
    "            real_batch[0].to(device)[:64], padding=5, normalize=True\n",
    "        ).cpu(),\n",
    "        (1, 2, 0),\n",
    "    )\n",
    ")\n",
    "\n",
    "# Plot the fake images from the last epoch\n",
    "plt.subplot(1, 2, 2)\n",
    "plt.axis(\"off\")\n",
    "plt.title(\"Fake Images\")\n",
    "plt.imshow(plt.imread(\"{}/epoch{}_generator.png\".format(trainer.recon, trainer.epochs)))\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "colab": {},
    "colab_type": "code",
    "id": "r1JjE0OTnSaJ"
   },
   "outputs": [],
   "source": [
    "fig = plt.figure(figsize=(8, 8))\n",
    "plt.axis(\"off\")\n",
    "ims = [\n",
    "    [plt.imshow(plt.imread(\"{}/epoch{}_generator.png\".format(trainer.recon, i)))]\n",
    "    for i in range(1, trainer.epochs + 1)\n",
    "]\n",
    "ani = animation.ArtistAnimation(fig, ims, interval=1000, repeat_delay=1000, blit=True)\n",
    "\n",
    "# Play the animation\n",
    "HTML(ani.to_jshtml())"
   ]
  }
 ],
 "metadata": {
  "accelerator": "GPU",
  "colab": {
   "name": " Adversarial Autoencoder Tutorial.ipynb",
   "provenance": [],
   "version": "0.3.2"
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 1
}
